🔧 Le Funzioni in C

Guida Completa, Esaustiva e Approfondita alla Programmazione Modulare

📚 Introduzione: Il Cuore della Programmazione Strutturata

Immaginiamo di dover scrivere un programma che gestisce un sistema di prenotazione alberghiera. Il programma deve calcolare il prezzo totale del soggiorno considerando il numero di notti, il tipo di camera, gli eventuali sconti, le tasse, e molto altro. Se dovessimo scrivere tutto il codice in un'unica sequenza lineare all'interno della funzione main(), ci troveremmo rapidamente con centinaia di righe di codice impossibili da leggere, manutenere e debuggare. Ogni volta che volessimo modificare il calcolo delle tasse, dovremmo cercare tra tutto il codice per trovare la sezione giusta, rischiando di modificare inavvertitamente altre parti del programma.

Ma c'è un problema ancora più fondamentale: in molte situazioni, dobbiamo eseguire la stessa operazione più volte in punti diversi del programma. Pensate al calcolo del prezzo: potrebbe essere necessario sia quando l'utente richiede un preventivo, sia quando conferma la prenotazione, sia quando stampa la fattura. Senza funzioni, dovremmo copiare e incollare lo stesso codice tre volte. Cosa succede quando scopriamo un bug nel calcolo? Dovremmo ricordarci di correggerlo in tutti e tre i posti, con il rischio concreto di dimenticarne qualcuno e introdurre inconsistenze nel comportamento del programma.

Le funzioni risolvono brillantemente questi problemi. Una funzione è un blocco di codice autonomo e riutilizzabile che esegue un compito specifico e ben definito. È come creare uno "strumento specializzato" che può essere usato ogni volta che serve, senza doverlo ricostruire da zero. Nel nostro esempio dell'hotel, potremmo creare una funzione calcolaPrezzo() che riceve i parametri necessari (numero notti, tipo camera, ecc.) e restituisce il prezzo finale. Questa funzione può essere chiamata da qualsiasi punto del programma, garantendo che il calcolo sia sempre coerente e che eventuali correzioni debbano essere fatte in un unico posto.

Le funzioni non sono solo uno strumento per evitare la duplicazione del codice. Rappresentano il fondamento stesso della programmazione strutturata, un paradigma che ha rivoluzionato lo sviluppo software negli anni '60 e '70. Prima delle funzioni (o "procedure" e "subroutine" come venivano chiamate), i programmi erano scritti usando l'istruzione GOTO, creando quello che Edsger Dijkstra chiamò famosamente "spaghetti code" – codice così intricato e disorganizzato da essere praticamente impossibile da comprendere e manutenere.

La programmazione con funzioni introduce il concetto di decomposizione funzionale: un problema complesso viene scomposto in sottoproblemi più piccoli e gestibili, ognuno affrontato da una funzione dedicata. Ogni funzione ha una responsabilità singola e ben definita (il principio della "Single Responsibility"), comunica con le altre attraverso parametri e valori di ritorno chiaramente specificati, e nasconde i dettagli implementativi dietro un'interfaccia pubblica pulita. Questo è ciò che chiamiamo astrazione e incapsulamento, concetti che ritroveremo anche nella programmazione orientata agli oggetti.

In C, le funzioni hanno caratteristiche peculiari che le rendono sia potenti che pericolose. A differenza di linguaggi più moderni, il C segue rigorosamente il paradigma del passaggio per valore: quando passi un argomento a una funzione, viene creata una copia di quel valore, e la funzione lavora sulla copia, non sull'originale. Questo significa che, per default, una funzione non può modificare le variabili del chiamante. Tuttavia, attraverso i puntatori (trattati in dettaglio nella lezione dedicata), possiamo simulare il passaggio per riferimento e permettere alle funzioni di modificare i dati del chiamante.

La gestione della memoria nelle funzioni C avviene principalmente attraverso lo stack, una regione di memoria organizzata in modo LIFO (Last In, First Out). Quando una funzione viene chiamata, viene creato un nuovo "stack frame" contenente i parametri della funzione, le variabili locali, e l'indirizzo di ritorno. Questo stack frame viene distrutto quando la funzione termina, rendendo inaccessibili tutte le variabili locali – un comportamento che può causare bug subdoli se non compreso correttamente. Approfondiremo questi meccanismi nelle sezioni dedicate.

In questa lezione esploreremo ogni aspetto delle funzioni in C con un livello di dettaglio senza precedenti. Partiremo dai concetti fondamentali della dichiarazione e definizione, comprendendo la differenza cruciale tra prototipo e implementazione. Analizzeremo in profondità il meccanismo di chiamata di funzione, il passaggio dei parametri, la gestione dello stack, e il valore di ritorno. Studieremo lo scope (visibilità) e il lifetime (durata) delle variabili, concetti fondamentali per capire come le funzioni interagiscono con i dati. Esamineremo tecniche avanzate come la ricorsione, i puntatori a funzione, le funzioni variadiche, e i callback. Vedremo le best practices per scrivere funzioni robuste, leggibili e manutenibili, e analizzeremo gli errori più comuni e come evitarli.

Al termine di questa lezione, non solo saprai scrivere funzioni in C, ma comprenderai intimamente come funzionano a livello di assembly e processore, perché sono progettate così, quali sono i trade-off delle diverse scelte progettuali, e come sfruttarle al meglio per costruire software professionale, efficiente e manutenibile. Preparati per un viaggio nel cuore della programmazione procedurale.

1. Concetti Fondamentali: Anatomia di una Funzione

1.1 Definizione Formale e Terminologia Essenziale

Una funzione, nel contesto della programmazione C, è un sottoprogramma indipendente che incapsula una sequenza di istruzioni destinate a svolgere un compito specifico. Ogni funzione è caratterizzata da quattro componenti fondamentali che ne definiscono completamente il comportamento e l'interfaccia:

🔑 Le Quattro Componenti Essenziali di una Funzione:
  • 1. Nome della Funzione (Identificatore): È l'identificatore univoco che permette di invocare la funzione. Deve seguire le stesse regole dei nomi di variabili in C: può contenere lettere, cifre e underscore, ma deve iniziare con una lettera o underscore. Per convenzione, i nomi di funzione in C sono tipicamente scritti in camelCase o snake_case. Un buon nome di funzione è descrittivo e indica chiaramente cosa fa la funzione (ad esempio: calcolaMedia, stampa_matrice, ordina_array).
  • 2. Tipo di Ritorno (Return Type): Specifica il tipo di dato che la funzione restituisce al chiamante dopo l'esecuzione. Può essere qualsiasi tipo valido in C: tipi primitivi (int, float, char, ecc.), puntatori, strutture, o void se la funzione non restituisce alcun valore. Il tipo di ritorno è parte integrante del contratto tra la funzione e il suo chiamante: chi chiama la funzione sa esattamente che tipo di dato aspettarsi in risposta.
  • 3. Lista dei Parametri (Parameter List): È la lista di variabili che la funzione accetta come input. Ogni parametro è specificato con il suo tipo e nome. I parametri sono separati da virgole e racchiusi tra parentesi tonde. Se una funzione non accetta parametri, si scrive (void) in C (anche se le parentesi vuote () sono accettate, hanno un significato leggermente diverso). I parametri definiscono l'interfaccia di input della funzione e determinano quali informazioni devono essere fornite per eseguirla correttamente.
  • 4. Corpo della Funzione (Function Body): È il blocco di codice racchiuso tra parentesi graffe { } che contiene le istruzioni da eseguire quando la funzione viene chiamata. Il corpo della funzione ha accesso ai parametri passati, può dichiarare variabili locali, può chiamare altre funzioni, e deve terminare con un'istruzione return se il tipo di ritorno non è void. Il corpo implementa la logica effettiva della funzione, trasformando gli input in output secondo l'algoritmo desiderato.

Comprendere la distinzione tra dichiarazione (o prototipo) e definizione di una funzione è fondamentale. La dichiarazione specifica solo la firma della funzione (nome, tipo di ritorno, e tipi dei parametri) senza fornire l'implementazione. Serve a informare il compilatore dell'esistenza della funzione prima che questa venga effettivamente utilizzata nel codice. La definizione, invece, include sia la firma che il corpo della funzione, fornendo l'implementazione completa.

// DICHIARAZIONE (Prototipo) - Tipicamente nel file header (.h)
// Comunica al compilatore che esiste una funzione con questa firma
int somma(int a, int b);

// DEFINIZIONE - Tipicamente nel file sorgente (.c)
// Fornisce l'implementazione effettiva della funzione
int somma(int a, int b) {
    return a + b;
}

// Anatomia completa di una definizione di funzione:
//
// [tipo_ritorno] [nome_funzione]([tipo_param1] [nome_param1], [tipo_param2] [nome_param2], ...)
// {
//     [corpo della funzione]
//     [return valore;]  // se tipo_ritorno != void
// }

// Esempio completo con tutte le componenti annotate:

float               // ← Tipo di ritorno: questa funzione restituisce un float
calcolaMedia      // ← Nome della funzione: identificatore descrittivo
(                  // ← Inizio lista parametri
    int *voti,      // ← Primo parametro: puntatore a int (array di voti)
    int numVoti     // ← Secondo parametro: numero di elementi nell'array
)                  // ← Fine lista parametri
{                  // ← Inizio corpo della funzione
    if (numVoti <= 0) return 0.0f;  // Validazione input
    
    int somma = 0;
    for (int i = 0; i < numVoti; i++) {
        somma += voti[i];
    }
    
    return (float)somma / numVoti;  // ← Istruzione return: restituisce il risultato
}                  // ← Fine corpo della funzione

1.2 Il Ruolo Cruciale dei Prototipi: Forward Declaration

Nel linguaggio C, il compilatore legge il codice sorgente dall'alto verso il basso, una riga alla volta. Questo significa che quando il compilatore incontra una chiamata a funzione, deve già conoscere la firma di quella funzione per verificare che la chiamata sia corretta (numero e tipo di argomenti corrispondenti). Se la funzione è definita dopo il punto in cui viene chiamata, il compilatore non la conosce ancora e genera un errore.

I prototipi di funzione risolvono questo problema attraverso quello che viene chiamato forward declaration (dichiarazione anticipata). Un prototipo è essenzialmente la firma della funzione seguita da un punto e virgola, senza il corpo. Inserendo i prototipi all'inizio del file sorgente (tipicamente nei file header .h), informiamo il compilatore dell'esistenza delle funzioni prima che vengano effettivamente utilizzate, permettendo così di chiamarle in qualsiasi ordine.

⚠️ Perché i Prototipi sono Fondamentali - Un Esempio Pratico:

Consideriamo il seguente scenario problematico senza prototipi:

// CODICE PROBLEMATICO - GENERA ERRORE DI COMPILAZIONE

int main(void) {
    int risultato = somma(5, 3);  // ERRORE! somma() non è ancora definita
    printf("Risultato: %d\n", risultato);
    return 0;
}

int somma(int a, int b) {
    return a + b;
}

// Il compilatore, arrivato alla linea con somma(5, 3), non sa ancora che
// la funzione somma() esiste, quali parametri accetta, e cosa restituisce.

La soluzione corretta usa un prototipo:

// CODICE CORRETTO - USA IL PROTOTIPO

// Prototipo: dichiara l'esistenza della funzione
int somma(int a, int b);

int main(void) {
    int risultato = somma(5, 3);  // OK! Il compilatore conosce somma()
    printf("Risultato: %d\n", risultato);
    return 0;
}

// Definizione: fornisce l'implementazione
int somma(int a, int b) {
    return a + b;
}

Un aspetto importante dei prototipi è che i nomi dei parametri sono opzionali – al compilatore interessa solo il tipo. Tuttavia, è considerata una best practice includerli per documentare il significato di ogni parametro:

// Prototipo minimale (valido ma poco documentativo)
float calcolaSconto(float, float);

// Prototipo documentativo (preferibile)
float calcolaSconto(float prezzoOriginale, float percentualeSconto);

// Quando qualcuno legge il prototipo, capisce immediatamente cosa fa la funzione
// senza dover cercare la definizione o consultare altra documentazione

2. Meccanismi di Passaggio dei Parametri: Pass by Value in Profondità

2.1 Il Paradigma del Passaggio per Valore: Copia Profonda dei Dati

Il C è un linguaggio che implementa rigorosamente il passaggio per valore (pass by value). Questo significa che quando passi una variabile come argomento a una funzione, il C non passa la variabile originale, ma crea una copia del suo valore. La funzione riceve e lavora sulla copia, non sull'originale. Di conseguenza, qualsiasi modifica apportata al parametro all'interno della funzione non ha alcun effetto sulla variabile originale nel contesto del chiamante.

Questo comportamento è radicalmente diverso da linguaggi come Java (per gli oggetti) o Python, dove i riferimenti agli oggetti vengono passati. In C, anche per tipi complessi come le strutture, viene sempre creata una copia completa. Questo ha implicazioni profonde sia per la semantica del linguaggio che per le prestazioni.

// Dimostrazione del passaggio per valore

#include <stdio.h>

// Funzione che tenta di modificare il parametro
void tentativoModifica(int x) {
    printf("  Dentro la funzione, prima: x = %d\n", x);
    x = 100;  // Modifica la COPIA locale
    printf("  Dentro la funzione, dopo: x = %d\n", x);
}

int main(void) {
    int numero = 42;
    
    printf("Prima della chiamata: numero = %d\n", numero);
    tentativoModifica(numero);
    printf("Dopo la chiamata: numero = %d\n", numero);  // numero è ancora 42!
    
    return 0;
}

/* OUTPUT:
Prima della chiamata: numero = 42
  Dentro la funzione, prima: x = 42
  Dentro la funzione, dopo: x = 100
Dopo la chiamata: numero = 42

Spiegazione: 
- Quando chiamiamo tentativoModifica(numero), viene creata una copia di 'numero'
- Questa copia viene assegnata al parametro 'x' della funzione
- La modifica 'x = 100' agisce solo sulla copia locale
- Quando la funzione termina, 'x' viene distrutto
- La variabile originale 'numero' rimane inalterata
*/

2.2 Analisi Approfondita: Cosa Succede nello Stack

Per comprendere veramente il passaggio per valore, dobbiamo esaminare cosa accade a livello di memoria durante una chiamata di funzione. Quando una funzione viene invocata, il sistema crea un nuovo stack frame (o activation record) sullo stack. Questo frame contiene:

Visualizzazione dello Stack durante una Chiamata di Funzione

Consideriamo questo codice: int main() { int a = 5; somma(a, 3); }

Stack Frame di somma()
parametro x: 5 (COPIA di a)
parametro y: 3
variabile locale risultato: ???
indirizzo di ritorno: 0x... ← Punto di ritorno in main
Stack Frame di main()
variabile a: 5 ← ORIGINALE inalterato
indirizzo di ritorno: 0x... ← Punto di ritorno al sistema operativo

Quando somma() modifica x, sta modificando solo la sua copia locale nello stack frame di somma(). La variabile a nel frame di main() rimane completamente isolata e inalterata. Quando somma() termina con return, il suo stack frame viene distrutto, liberando tutta la memoria locale, e l'esecuzione riprende in main() con tutte le sue variabili intatte.

2.3 Simulare il Passaggio per Riferimento con i Puntatori

Se vogliamo che una funzione modifichi effettivamente una variabile del chiamante, dobbiamo passare un puntatore a quella variabile. Tecnicamente questo è ancora passaggio per valore (stiamo passando il valore dell'indirizzo di memoria), ma l'effetto pratico è quello del passaggio per riferimento: la funzione può accedere e modificare direttamente la variabile originale.

📌 Nota sui Puntatori:

I puntatori sono un argomento complesso e fondamentale in C, trattato in dettaglio nella lezione dedicata ai puntatori. Qui forniamo solo un'introduzione essenziale per comprendere il passaggio di parametri. Per una comprensione approfondita dell'aritmetica dei puntatori, della dereferenziazione, e delle best practices, si rimanda alla lezione specifica.

Visualizza esempio: Passaggio per "riferimento" tramite puntatori
#include <stdio.h>

// Funzione che modifica la variabile originale tramite puntatore
void modificaEffettiva(int *ptr) {  // ← Parametro è un PUNTATORE a int
    printf("  Dentro la funzione, indirizzo: %p\n", (void*)ptr);
    printf("  Dentro la funzione, prima: *ptr = %d\n", *ptr);
    
    *ptr = 100;  // ← Dereferenzia il puntatore e modifica il valore puntato
    
    printf("  Dentro la funzione, dopo: *ptr = %d\n", *ptr);
}

int main(void) {
    int numero = 42;
    
    printf("Indirizzo di numero: %p\n", (void*)&numero);
    printf("Prima della chiamata: numero = %d\n", numero);
    
    modificaEffettiva(&numero);  // ← Passiamo l'INDIRIZZO di numero
    
    printf("Dopo la chiamata: numero = %d\n", numero);  // numero È CAMBIATO!
    
    return 0;
}

/* OUTPUT (indirizzi variano):
Indirizzo di numero: 0x7ffe8b4c2d1c
Prima della chiamata: numero = 42
  Dentro la funzione, indirizzo: 0x7ffe8b4c2d1c  ← Stesso indirizzo!
  Dentro la funzione, prima: *ptr = 42
  Dentro la funzione, dopo: *ptr = 100
Dopo la chiamata: numero = 100  ← MODIFICATO!

Spiegazione dettagliata:
1. &numero è l'operatore 'address-of': restituisce l'indirizzo di memoria di numero
2. Passiamo questo indirizzo alla funzione: modificaEffettiva(&numero)
3. Il parametro 'ptr' riceve una COPIA dell'indirizzo (pass by value dell'indirizzo)
4. Attraverso l'operatore * (dereferenziazione), accediamo al valore all'indirizzo puntato
5. *ptr = 100 modifica il contenuto della memoria puntata, cioè la variabile originale!
*/

2.4 Passaggio di Array: Un Caso Speciale

Gli array in C hanno un comportamento particolare quando passati a funzioni. Il nome di un array, quando usato come argomento in una chiamata di funzione, "decade" automaticamente in un puntatore al suo primo elemento. Questo significa che, nonostante il C usi il passaggio per valore, quando passi un array stai effettivamente passando un puntatore alla sua prima posizione. Di conseguenza, le modifiche agli elementi dell'array fatte all'interno della funzione si riflettono sull'array originale.

⚠️ Importante: Array e Decay to Pointer

Quando un array viene passato a una funzione:

  • L'array "decade" in un puntatore al primo elemento
  • La funzione non conosce la dimensione dell'array (informazione persa)
  • È necessario passare la dimensione come parametro separato
  • Le modifiche agli elementi si riflettono sull'array originale

Per approfondimenti sulla relazione tra array e puntatori, consultare la lezione sugli array dove questo argomento è trattato in dettaglio.

Visualizza esempio: Passaggio di array a funzioni
#include <stdio.h>

// Le seguenti dichiarazioni sono EQUIVALENTI in C:
void elabora_array_v1(int arr[], int size);      // Sintassi con []
void elabora_array_v2(int *arr, int size);      // Sintassi con puntatore
// Entrambe significano: "arr è un puntatore a int"

// Implementazione
void elabora_array_v1(int arr[], int size) {
    printf("Dimensione del 'parametro arr': %zu byte\n", sizeof(arr));
    // ⚠️ sizeof(arr) restituisce la dimensione di un PUNTATORE (8 byte su 64-bit),
    //    NON la dimensione dell'array originale!
    
    printf("Modifico gli elementi...\n");
    for (int i = 0; i < size; i++) {
        arr[i] *= 2;  // Modifica l'array ORIGINALE
    }
}

int main(void) {
    int numeri[] = {1, 2, 3, 4, 5};
    int size = sizeof(numeri) / sizeof(numeri[0]);
    
    printf("Dimensione dell'array originale: %d elementi (%zu byte)\n", 
           size, sizeof(numeri));
    
    printf("Array prima: ");
    for (int i = 0; i < size; i++) {
        printf("%d ", numeri[i]);
    }
    printf("\n");
    
    elabora_array_v1(numeri, size);
    
    printf("Array dopo: ");
    for (int i = 0; i < size; i++) {
        printf("%d ", numeri[i]);
    }
    printf("\n");
    
    return 0;
}

/* OUTPUT:
Dimensione dell'array originale: 5 elementi (20 byte)
Array prima: 1 2 3 4 5
Dimensione del 'parametro arr': 8 byte
Modifico gli elementi...
Array dopo: 2 4 6 8 10

Osservazioni chiave:
1. sizeof(numeri) in main() = 20 byte (5 int × 4 byte)
2. sizeof(arr) nella funzione = 8 byte (dimensione di un puntatore su sistema 64-bit)
3. Le modifiche agli elementi si riflettono sull'array originale
4. È ESSENZIALE passare la dimensione come parametro separato
*/

3. Valori di Ritorno e Istruzione return

3.1 Il Meccanismo del Valore di Ritorno

L'istruzione return serve a due scopi fondamentali: (1) terminare immediatamente l'esecuzione della funzione, e (2) restituire un valore al chiamante (se la funzione non è di tipo void). Quando viene eseguita un'istruzione return, il flusso di controllo ritorna immediatamente al punto di chiamata, e qualsiasi codice successivo al return nella funzione viene ignorato.

// Sintassi e semantica di return

int calcola_massimo(int a, int b) {
    if (a > b) {
        return a;  // Esce immediatamente, restituendo a
    }
    return b;  // Se arriviamo qui, b >= a
}

// Una funzione può avere più istruzioni return
int classifica_voto(int voto) {
    if (voto < 0 || voto > 100) return -1;    // Errore: voto non valido
    if (voto >= 90) return 5;                 // Eccellente
    if (voto >= 80) return 4;                 // Ottimo
    if (voto >= 70) return 3;                 // Buono
    if (voto >= 60) return 2;                 // Sufficiente
    return 1;                                     // Insufficiente
}

// Funzione void: return senza valore
void stampa_messaggio(const char *msg) {
    if (msg == NULL) {
        return;  // Esce precocemente se msg è NULL
    }
    printf("%s\n", msg);
    // return implicito alla fine di funzioni void
}
🚫 Errore Grave: Funzione Non-Void Senza Return

Se una funzione dichiara un tipo di ritorno diverso da void ma non tutte le sue code paths terminano con un return, il comportamento è undefined. Il valore restituito sarà garbage (imprevedibile). Questo è uno degli errori più insidiosi in C perché il compilatore potrebbe non avvertirti sempre:

// CODICE PERICOLOSO - COMPORTAMENTO NON DEFINITO!
int calcola_valore(int x) {
    if (x > 0) {
        return x * 2;
    }
    // ⚠️ MANCA RETURN per il caso x <= 0!
    // Se x <= 0, la funzione "cade fuori" senza restituire nulla
    // Il valore restituito sarà IMPREVEDIBILE (undefined behavior)
}

// VERSIONE CORRETTA
int calcola_valore_corretto(int x) {
    if (x > 0) {
        return x * 2;
    }
    return 0;  // Gestisce esplicitamente il caso x <= 0
}

Best Practice: Compila sempre con i warning abilitati (-Wall -Wextra in GCC/Clang) per rilevare return mancanti.

4. Scope e Lifetime delle Variabili

4.1 Lo Scope (Visibilità): Dove una Variabile è Accessibile

Lo scope di una variabile definisce la regione del codice in cui quella variabile è visibile e può essere utilizzata. In C esistono principalmente quattro tipi di scope:

Visualizza esempio completo: Scope delle variabili
#include <stdio.h>

// Variabile GLOBALE - File scope
// Visibile in tutto il file, dopo la sua dichiarazione
int contatore_globale = 0;

void funzione1(void) {
    // 'x' ha block scope - visibile solo in questa funzione
    int x = 10;
    
    contatore_globale++;  // Accesso alla globale: OK
    printf("funzione1 - x: %d, globale: %d\n", x, contatore_globale);
    
    {
        // Blocco innestato - nuovo scope
        int y = 20;  // 'y' visibile solo in questo blocco
        int x = 100; // Questa 'x' NASCONDE la 'x' esterna (shadowing)
        
        printf("  Blocco interno - x: %d, y: %d\n", x, y);
    }
    
    // Qui 'y' NON esiste più (out of scope)
    // printf("%d", y);  ← ERRORE DI COMPILAZIONE!
    
    // La 'x' originale è di nuovo visibile
    printf("funzione1 - x dopo blocco: %d\n", x);  // Stampa 10, non 100
}

void funzione2(void) {
    // Questa funzione NON può accedere alla 'x' di funzione1
    // Ma può accedere alla variabile globale
    contatore_globale++;
    printf("funzione2 - globale: %d\n", contatore_globale);
    
    // printf("%d", x);  ← ERRORE! 'x' non è visibile qui
}

int main(void) {
    printf("=== Dimostrazione Scope ===\n");
    
    funzione1();
    funzione2();
    funzione1();
    
    printf("main - globale finale: %d\n", contatore_globale);
    
    return 0;
}

/* Concetti chiave:
1. Variabili locali (block scope) sono ISOLATE tra funzioni diverse
2. Variabili globali sono CONDIVISE tra tutte le funzioni
3. Blocchi innestati possono "nascondere" variabili esterne (shadowing)
4. Quando un blocco termina, tutte le sue variabili locali diventano inaccessibili
*/

4.2 Il Lifetime (Durata): Quando una Variabile Esiste in Memoria

Il lifetime di una variabile definisce per quanto tempo quella variabile esiste fisicamente in memoria. È distinto dallo scope: una variabile può esistere in memoria ma non essere accessibile (fuori scope). In C, il lifetime dipende dalla storage class della variabile:

Visualizza esempio: Automatic vs Static Storage
#include <stdio.h>

void conta_chiamate_automatica(void) {
    int contatore = 0;  // Variabile AUTOMATICA (locale)
    contatore++;
    printf("Conta automatica: %d\n", contatore);
    // Al return, 'contatore' viene DISTRUTTO
}

void conta_chiamate_statica(void) {
    static int contatore = 0;  // Variabile STATICA
    // 'static' qui significa: "esiste per tutta la durata del programma"
    // L'inizializzazione '= 0' avviene UNA SOLA VOLTA, non ad ogni chiamata
    
    contatore++;
    printf("Conta statica: %d\n", contatore);
    // Al return, 'contatore' NON viene distrutto, mantiene il suo valore
}

int main(void) {
    printf("=== Chiamate alla funzione con variabile automatica ===\n");
    conta_chiamate_automatica();  // Stampa: 1
    conta_chiamate_automatica();  // Stampa: 1  ← Sempre 1!
    conta_chiamate_automatica();  // Stampa: 1
    
    printf("\n=== Chiamate alla funzione con variabile statica ===\n");
    conta_chiamate_statica();     // Stampa: 1
    conta_chiamate_statica();     // Stampa: 2  ← Incrementa!
    conta_chiamate_statica();     // Stampa: 3
    
    return 0;
}

/* Spiegazione:
Variabile AUTOMATICA:
- Viene creata ogni volta che la funzione viene chiamata
- Viene inizializzata a 0 ogni volta
- Viene distrutta quando la funzione termina
- Non conserva il valore tra chiamate successive

Variabile STATICA:
- Viene creata UNA SOLA VOLTA all'avvio del programma
- L'inizializzazione '= 0' avviene solo la prima volta
- NON viene distrutta quando la funzione termina
- Conserva il valore tra chiamate successive
- Ma ha ancora block scope: non accessibile fuori dalla funzione
*/

5. Ricorsione: Funzioni che Chiamano Se Stesse

5.1 Concetto di Ricorsione e Caso Base

La ricorsione è una tecnica di programmazione in cui una funzione chiama se stessa, direttamente o indirettamente. È un concetto potente che permette di risolvere problemi complessi scomponendoli in istanze più piccole dello stesso problema. Ogni chiamata ricorsiva crea un nuovo stack frame, con le proprie copie locali delle variabili e dei parametri.

Una funzione ricorsiva deve sempre avere un caso base (o condizione di terminazione): una situazione in cui la funzione restituisce un valore senza effettuare ulteriori chiamate ricorsive. Senza un caso base corretto, la funzione continuerebbe a chiamarsi all'infinito, causando uno stack overflow.

Visualizza esempio classico: Fattoriale ricorsivo
#include <stdio.h>

/**
 * Calcola il fattoriale di n ricorsivamente
 * Definizione matematica:
 *   n! = n × (n-1)!  per n > 0
 *   0! = 1           (caso base)
 */
unsigned long long fattoriale(int n) {
    // CASO BASE: fondamentale per fermare la ricorsione
    if (n == 0 || n == 1) {
        return 1;
    }
    
    // CASO RICORSIVO: riduce il problema a un'istanza più piccola
    return n * fattoriale(n - 1);
}

// Versione con tracciamento per capire il flusso di esecuzione
unsigned long long fattoriale_tracciato(int n, int livello) {
    // Stampa indentazione proporzionale al livello di ricorsione
    for (int i = 0; i < livello; i++) printf("  ");
    printf("→ fattoriale(%d) chiamato\n", n);
    
    if (n == 0 || n == 1) {
        for (int i = 0; i < livello; i++) printf("  ");
        printf("← fattoriale(%d) restituisce 1 (caso base)\n", n);
        return 1;
    }
    
    unsigned long long risultato = n * fattoriale_tracciato(n - 1, livello + 1);
    
    for (int i = 0; i < livello; i++) printf("  ");
    printf("← fattoriale(%d) restituisce %llu\n", n, risultato);
    
    return risultato;
}

int main(void) {
    int n = 5;
    
    printf("Calcolo di %d! = %llu\n\n", n, fattoriale(n));
    
    printf("\n=== Tracciamento delle chiamate ricorsive ===\n");
    fattoriale_tracciato(5, 0);
    
    return 0;
}

/* OUTPUT del tracciamento:
→ fattoriale(5) chiamato
  → fattoriale(4) chiamato
    → fattoriale(3) chiamato
      → fattoriale(2) chiamato
        → fattoriale(1) chiamato
        ← fattoriale(1) restituisce 1 (caso base)
      ← fattoriale(2) restituisce 2
    ← fattoriale(3) restituisce 6
  ← fattoriale(4) restituisce 24
← fattoriale(5) restituisce 120

Analisi del flusso:
1. fattoriale(5) chiama fattoriale(4)
2. fattoriale(4) chiama fattoriale(3)
3. ... fino a fattoriale(1) che raggiunge il CASO BASE
4. fattoriale(1) restituisce 1
5. Questo valore risale attraverso lo stack:
   - fattoriale(2) calcola 2 × 1 = 2
   - fattoriale(3) calcola 3 × 2 = 6
   - fattoriale(4) calcola 4 × 6 = 24
   - fattoriale(5) calcola 5 × 24 = 120
*/
⚠️ Pericoli della Ricorsione: Stack Overflow

Ogni chiamata ricorsiva consuma spazio sullo stack per creare un nuovo stack frame. Se la ricorsione è troppo profonda (troppi livelli), lo stack può esaurirsi, causando uno stack overflow – un crash del programma:

// CODICE PERICOLOSO - CAUSA STACK OVERFLOW!
unsigned long long fattoriale_senza_limite(int n) {
    if (n == 0) return 1;
    return n * fattoriale_senza_limite(n - 1);
}

// Se chiami fattoriale_senza_limite(-1), il caso base non viene mai raggiunto:
// fattoriale(-1) chiama fattoriale(-2) chiama fattoriale(-3) ... all'infinito!
// Dopo centinaia/migliaia di chiamate, lo stack esplode.

// VERSIONE SICURA - CON VALIDAZIONE
unsigned long long fattoriale_sicuro(int n) {
    if (n < 0) {
        fprintf(stderr, "Errore: fattoriale di numero negativo!\n");
        return 0;  // O gestisci l'errore in altro modo
    }
    if (n == 0 || n == 1) return 1;
    if (n > 20) {
        fprintf(stderr, "Errore: fattoriale troppo grande (overflow)!\n");
        return 0;
    }
    return n * fattoriale_sicuro(n - 1);
}

Best Practices per la Ricorsione:

  • Assicurati SEMPRE che esista un caso base raggiungibile
  • Valida gli input per evitare ricorsioni infinite
  • Considera limiti sulla profondità di ricorsione per input grandi
  • Per problemi molto grandi, considera soluzioni iterative (con loop)
Visualizza esempio avanzato: Sequenza di Fibonacci ricorsiva vs iterativa
#include <stdio.h>
#include <time.h>

/**
 * Fibonacci ricorsivo INGENUO (molto inefficiente!)
 * Complessità: O(2^n) - esponenziale!
 * 
 * Definizione: fib(n) = fib(n-1) + fib(n-2)
 *              fib(0) = 0, fib(1) = 1
 */
unsigned long long fib_ricorsivo(int n) {
    if (n <= 1) return n;
    return fib_ricorsivo(n - 1) + fib_ricorsivo(n - 2);
}

/**
 * Fibonacci iterativo (molto più efficiente!)
 * Complessità: O(n) - lineare
 */
unsigned long long fib_iterativo(int n) {
    if (n <= 1) return n;
    
    unsigned long long prev = 0, curr = 1;
    
    for (int i = 2; i <= n; i++) {
        unsigned long long next = prev + curr;
        prev = curr;
        curr = next;
    }
    
    return curr;
}

int main(void) {
    int n = 40;
    
    printf("Calcolo di Fibonacci(%d)\n\n", n);
    
    // Test versione ricorsiva (LENTO per n > 35)
    printf("Versione ricorsiva...\n");
    clock_t start = clock();
    unsigned long long ris_ric = fib_ricorsivo(n);
    clock_t end = clock();
    double tempo_ric = ((double)(end - start)) / CLOCKS_PER_SEC;
    
    printf("Risultato: %llu\n", ris_ric);
    printf("Tempo: %.6f secondi\n\n", tempo_ric);
    
    // Test versione iterativa (VELOCE)
    printf("Versione iterativa...\n");
    start = clock();
    unsigned long long ris_iter = fib_iterativo(n);
    end = clock();
    double tempo_iter = ((double)(end - start)) / CLOCKS_PER_SEC;
    
    printf("Risultato: %llu\n", ris_iter);
    printf("Tempo: %.6f secondi\n\n", tempo_iter);
    
    printf("Speedup: %.0fx più veloce!\n", tempo_ric / tempo_iter);
    
    return 0;
}

/* OUTPUT tipico:
Versione ricorsiva...
Risultato: 102334155
Tempo: 1.523400 secondi

Versione iterativa...
Risultato: 102334155
Tempo: 0.000001 secondi

Speedup: 1523400x più veloce!

Perché la ricorsione è così lenta qui?
- fib_ricorsivo(5) calcola fib(4) e fib(3)
- Ma fib(4) ricalcola fib(3) e fib(2)!
- fib(3) viene calcolato DUE VOLTE
- Questa duplicazione cresce esponenzialmente
- Per fib(40), ci sono MILIARDI di calcoli ridondanti!

La versione iterativa calcola ogni fib(i) UNA SOLA VOLTA.
Lezione: la ricorsione non è sempre la soluzione migliore!
*/